Skip to content

feat(react-from): extend appform#2106

Open
harry-whorlow wants to merge 7 commits intomainfrom
extend-appform
Open

feat(react-from): extend appform#2106
harry-whorlow wants to merge 7 commits intomainfrom
extend-appform

Conversation

@harry-whorlow
Copy link
Copy Markdown
Contributor

@harry-whorlow harry-whorlow commented Apr 2, 2026

Summary by CodeRabbit

  • New Features

    • Added a way to extend custom forms with additional field and form components at runtime.
  • Examples

    • Added a runnable React form composition example app (dev/build/preview scripts, entrypoint, demo UI, configs, and instructions).
  • Documentation

    • Expanded the form composition guide with an “Extending custom appForm” section and corrected a type-safety example.
  • Tests

    • Added test suites validating form-extension behavior, merge/override rules, uniqueness checks, and repeatable extensions.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 2, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 674c7fff-11d4-4ba5-8ee8-7f367476e116

📥 Commits

Reviewing files that changed from the base of the PR and between e628103 and 44898c5.

📒 Files selected for processing (1)
  • packages/react-form/src/createFormHook.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-form/src/createFormHook.tsx

📝 Walkthrough

Walkthrough

Adds an extendable appForm API (extendForm) to merge and reuse component maps, example app and docs demonstrating extension patterns, example project scaffolding (Vite/TS/ESLint), and tests validating extend/merge behavior and compile-time uniqueness checks.

Changes

Cohort / File(s) Summary
Docs
docs/framework/react/guides/form-composition.md
Fixed a mistyped field name (firstName) and added an "Extending custom appForm" section demonstrating exporting a prebuilt appForm and extending it via extendForm(...), including downstream-only field components and overrides.
Example scaffold & config
examples/react/composition/{.eslintrc.cjs, .gitignore, README.md, package.json, tsconfig.json, index.html}
Added a Vite + React example project scaffold with ESLint config, ignore rules, README, package.json (scripts/deps), TypeScript config, and HTML entrypoint.
Example source
examples/react/composition/src/...
examples/react/composition/src/AppForm/AppForm.tsx, .../FieldComponents/TextField.tsx, .../FormComponents/SubmitButton.tsx, examples/react/composition/src/index.tsx
Implemented AppForm wiring (contexts/hooks) and exported useAppForm, added TextField and SubmitButton components, and an App demonstrating validators (sync + async), submission flow, and devtools integration.
Core library API
packages/react-form/src/createFormHook.tsx
Added extendForm(extension) to the return of createFormHook, which merges provided fieldComponents/formComponents over the base, returns a new hook that reuses the same contexts, and uses generics to enforce compile-time uniqueness of component names.
Tests
packages/react-form/tests/{createFormHook.test-d.tsx, createFormHook.test.tsx}
Added type-level and runtime tests for extendForm: merging/availability of parent and extended field/form components, chaining multiple extensions, withForm compatibility, and compile-time errors for duplicate component names.

Sequence Diagram(s)

sequenceDiagram
    participant Dev as Downstream Dev
    participant Base as createFormHook()
    participant Extend as extendForm()
    participant Hook as Extended Hook
    participant App as App (uses hook)

    Dev->>Base: createFormHook({ fieldComponents, formComponents })
    Base-->>Dev: baseHook (useAppForm, contexts, component maps)

    Dev->>Extend: baseHook.extendForm({ fieldComponents?, formComponents? })
    Note over Extend: Merge base + extension\nCompile-time uniqueness checks for overlapping keys
    Extend-->>Dev: extendedHook (reused contexts, merged component maps)

    App->>Hook: useExtendedHook() / render
    Hook-->>App: AppField/Form components (parent + extended components available)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I nudged new pieces into the form,
Base parts stayed, new parts took form,
Extend a field, attach a botton,
Hooray — the form grows, hop-hop, so fun! 🥕

🚥 Pre-merge checks | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Description check ⚠️ Warning The pull request description is completely empty; the author did not fill in any of the required template sections including 'Changes' and 'Checklist'. Add a description explaining the motivation and implementation of the extendForm feature, and complete the checklist items per the repository template.
Docstring Coverage ⚠️ Warning Docstring coverage is 5.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title "feat(react-from): extend appform" is related to the main changes but contains a typo (react-from instead of react-form) and is vague about what 'extend appform' means. Clarify the title to describe the specific feature being added (e.g., 'feat(react-form): add extendForm method to createFormHook') and fix the typo.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch extend-appform

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nx-cloud
Copy link
Copy Markdown

nx-cloud bot commented Apr 2, 2026

View your CI Pipeline Execution ↗ for commit 44898c5

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded <1s View ↗
nx run-many --target=build --exclude=examples/** ✅ Succeeded 2s View ↗

☁️ Nx Cloud last updated this comment at 2026-04-10 11:01:49 UTC

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 2, 2026

🚀 Changeset Version Preview

1 package(s) bumped directly, 12 bumped as dependents.

🟩 Patch bumps

Package Version Reason
@tanstack/svelte-form 1.28.6 → 1.28.7 Changeset
@tanstack/angular-form 1.28.6 → 1.28.7 Dependent
@tanstack/form-core 1.28.6 → 1.28.7 Dependent
@tanstack/form-devtools 0.2.20 → 0.2.21 Dependent
@tanstack/lit-form 1.23.26 → 1.23.27 Dependent
@tanstack/react-form 1.28.6 → 1.28.7 Dependent
@tanstack/react-form-devtools 0.2.20 → 0.2.21 Dependent
@tanstack/react-form-nextjs 1.28.6 → 1.28.7 Dependent
@tanstack/react-form-remix 1.28.6 → 1.28.7 Dependent
@tanstack/react-form-start 1.28.6 → 1.28.7 Dependent
@tanstack/solid-form 1.28.6 → 1.28.7 Dependent
@tanstack/solid-form-devtools 0.2.20 → 0.2.21 Dependent
@tanstack/vue-form 1.28.6 → 1.28.7 Dependent

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 2, 2026

More templates

@tanstack/angular-form

npm i https://pkg.pr.new/@tanstack/angular-form@2106

@tanstack/form-core

npm i https://pkg.pr.new/@tanstack/form-core@2106

@tanstack/form-devtools

npm i https://pkg.pr.new/@tanstack/form-devtools@2106

@tanstack/lit-form

npm i https://pkg.pr.new/@tanstack/lit-form@2106

@tanstack/react-form

npm i https://pkg.pr.new/@tanstack/react-form@2106

@tanstack/react-form-devtools

npm i https://pkg.pr.new/@tanstack/react-form-devtools@2106

@tanstack/react-form-nextjs

npm i https://pkg.pr.new/@tanstack/react-form-nextjs@2106

@tanstack/react-form-remix

npm i https://pkg.pr.new/@tanstack/react-form-remix@2106

@tanstack/react-form-start

npm i https://pkg.pr.new/@tanstack/react-form-start@2106

@tanstack/solid-form

npm i https://pkg.pr.new/@tanstack/solid-form@2106

@tanstack/solid-form-devtools

npm i https://pkg.pr.new/@tanstack/solid-form-devtools@2106

@tanstack/svelte-form

npm i https://pkg.pr.new/@tanstack/svelte-form@2106

@tanstack/vue-form

npm i https://pkg.pr.new/@tanstack/vue-form@2106

commit: 44898c5

@sentry
Copy link
Copy Markdown

sentry bot commented Apr 2, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.25%. Comparing base (6892ed0) to head (44898c5).
⚠️ Report is 162 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2106      +/-   ##
==========================================
- Coverage   90.35%   90.25%   -0.10%     
==========================================
  Files          38       49      +11     
  Lines        1752     2043     +291     
  Branches      444      532      +88     
==========================================
+ Hits         1583     1844     +261     
- Misses        149      179      +30     
  Partials       20       20              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@harry-whorlow harry-whorlow marked this pull request as ready for review April 6, 2026 19:59
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (5)
packages/react-form/tests/createFormHook.test-d.tsx (1)

993-1021: The chaining type test never proves ThirdField survived the second extension.

This case creates ThirdField, but the assertions still only cover Test and ExtendedField, then stop at expectTypeOf(doublyExtendedForm.AppField).toBeFunction(). A regression where the second extendForm drops the new key would still pass.

Suggested fix
-      const { useAppForm: useDoublyExtended } = baseHook
+      const {
+        useAppForm: useDoublyExtended,
+        withForm: withDoublyExtendedForm,
+      } = baseHook
         .extendForm({ fieldComponents: { ExtendedField } })
         .extendForm({ fieldComponents: { ThirdField } })
 
-      withExtendedForm({
+      withDoublyExtendedForm({
         defaultValues: { name: '' },
         render: ({ form }) => {
           return (
             <form.AppField name="name">
               {(field) => {
                 expectTypeOf(field.Test).toBeFunction()
                 expectTypeOf(field.ExtendedField).toBeFunction()
+                expectTypeOf(field.ThirdField).toBeFunction()
                 return null
               }}
             </form.AppField>
           )
         },
       })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-form/tests/createFormHook.test-d.tsx` around lines 993 - 1021,
The test never asserts that ThirdField survived the second extendForm call;
update the test around useDoublyExtended/withExtendedForm to assert ThirdField
is present — add expectTypeOf(field.ThirdField).toBeFunction() inside the render
callback (alongside the existing Test and ExtendedField checks) and also assert
the doublyExtendedForm exposes the extended field (e.g., an assertion that the
useDoublyExtended result exposes ThirdField on AppField or the equivalent
exported shape) so the chaining regression would fail if the second extension
drops the key.
examples/react/composition/index.html (1)

9-9: Title doesn't match the example name.

The title says "Simple Example App" but this is the composition example. Consider updating to reflect the actual example purpose.

-    <title>TanStack Form React Simple Example App</title>
+    <title>TanStack Form React Composition Example App</title>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/react/composition/index.html` at line 9, Update the HTML <title>
element to reflect this is the "Composition" example rather than "Simple Example
App": change the title text content inside the <title> tag (currently "TanStack
Form React Simple Example App") to something like "TanStack Form React
Composition Example" so it accurately describes this example page.
examples/react/composition/src/index.tsx (2)

43-48: Async validator returns false instead of undefined when valid.

When value.includes('error') is false, the && short-circuit returns false. While TanStack Form may handle this, explicitly returning undefined for valid state is clearer and more conventional.

             onChangeAsync: async ({ value }) => {
               await new Promise((resolve) => setTimeout(resolve, 1000))
-              return (
-                value.includes('error') && 'No "error" allowed in first name'
-              )
+              return value.includes('error')
+                ? 'No "error" allowed in first name'
+                : undefined
             },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/react/composition/src/index.tsx` around lines 43 - 48, The async
validator onChangeAsync currently returns the string error when
value.includes('error') is true but returns false when valid due to the &&
expression; update the onChangeAsync implementation (the async validator
function) to explicitly return undefined for valid input instead of false—e.g.,
replace the short-circuit expression with a conditional (ternary or if) that
returns 'No "error" allowed in first name' when value.includes('error') is true
and undefined otherwise.

54-56: Consider using a distinct label for the lastName field.

Both fields currently display "last name". The second one is correct, but you may want to capitalize it consistently (e.g., "Last Name").

-          {(f) => <f.TextField label="last name" />}
+          {(f) => <f.TextField label="Last Name" />}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/react/composition/src/index.tsx` around lines 54 - 56, The lastName
field's visible label is not distinct/capitalized; update the form.AppField for
name="lastName" to use a proper, distinct label (e.g., change the f.TextField
label prop to "Last Name") and verify the other field (form.AppField
name="firstName") uses "First Name" so both fields are correctly labeled and
consistently capitalized; look for usages of form.AppField and the f.TextField
label prop to make this change.
examples/react/composition/src/AppForm/AppForm.tsx (1)

16-21: Consider exporting extendForm for downstream composition.

Given this PR introduces form composition capabilities via extendForm, you might want to export it alongside useAppForm so downstream consumers can extend this form with additional components.

-const { useAppForm } = createFormHook({
+const { useAppForm, extendForm } = createFormHook({
   fieldContext,
   formContext,
   fieldComponents: { TextField: TextField },
   formComponents: { SubmitButton },
 })
+
+export { extendForm }

Also, minor: TextField: TextField can use object shorthand: TextField.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/react/composition/src/AppForm/AppForm.tsx` around lines 16 - 21, The
export currently only exposes useAppForm from createFormHook; update the
destructuring returned by createFormHook to also export extendForm so downstream
modules can compose/extend the form (referencing createFormHook, useAppForm,
extendForm), and simplify the fieldComponents object by using shorthand for
TextField (replace TextField: TextField with TextField) while keeping
SubmitButton in formComponents.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/framework/react/guides/form-composition.md`:
- Around line 209-220: The example is incorrect: the package import casing was
changed and the example claims a name collision with CustomSubmit even though
ProfileForm defines SubmitButton; revert the import to the original casing
('weyland-yutan-corp/forms') and demonstrate a real collision by using the same
symbol name that ProfileForm actually exposes (e.g., use SubmitButton instead of
CustomSubmit) so TypeScript will error; update the export line that calls
ProfileForm.extendForm (reference: ProfileForm, useAppForm, CustomTextField,
CustomSubmit, SubmitButton) to import from the correctly-cased package and to
pass a formComponents object that actually collides with the parent
(SubmitButton) rather than CustomSubmit.

In `@examples/react/composition/src/AppForm/FieldComponents/TextField.tsx`:
- Around line 9-12: The TextField input is missing an onBlur handler so
field.state.meta.isTouched and onBlur validators won't update; update the input
rendered in the TextField component to add an onBlur that calls the form field's
blur handler (e.g., invoke field.handleBlur or the appropriate blur method on
the field object) so the field's touched state and onBlur validation run
correctly.

In `@examples/react/composition/src/index.tsx`:
- Around line 50-52: The firstName field in the form is using the wrong label;
update the AppField rendering for the firstName field (the instance that calls
form.AppField and renders {(f) => <f.TextField label="last name" />} ) to use
the correct label "first name" (i.e., change the label prop on f.TextField to
"first name" for the firstName AppField).

In `@packages/react-form/src/createFormHook.tsx`:
- Around line 599-617: extendForm currently only prevents extending keys that
exist in the parent maps but doesn't block names that collide with core runtime
APIs (e.g., AppForm, AppField, Field, Subscribe, handleChange) and thus can
silently override core behavior; modify extendForm to declare a reservedNames
set (include core exported API and runtime member names) and validate
extension.fieldComponents and extension.formComponents against that set,
rejecting/throwing with a clear error if any key from TNewField or TNewForm
appears in reservedNames; reference the extendForm function and the
fieldComponents/formComponents merge locations so the checks run before calling
createFormHook and before the cast to TComponents & TNewField / TFormComponents
& TNewForm.

---

Nitpick comments:
In `@examples/react/composition/index.html`:
- Line 9: Update the HTML <title> element to reflect this is the "Composition"
example rather than "Simple Example App": change the title text content inside
the <title> tag (currently "TanStack Form React Simple Example App") to
something like "TanStack Form React Composition Example" so it accurately
describes this example page.

In `@examples/react/composition/src/AppForm/AppForm.tsx`:
- Around line 16-21: The export currently only exposes useAppForm from
createFormHook; update the destructuring returned by createFormHook to also
export extendForm so downstream modules can compose/extend the form (referencing
createFormHook, useAppForm, extendForm), and simplify the fieldComponents object
by using shorthand for TextField (replace TextField: TextField with TextField)
while keeping SubmitButton in formComponents.

In `@examples/react/composition/src/index.tsx`:
- Around line 43-48: The async validator onChangeAsync currently returns the
string error when value.includes('error') is true but returns false when valid
due to the && expression; update the onChangeAsync implementation (the async
validator function) to explicitly return undefined for valid input instead of
false—e.g., replace the short-circuit expression with a conditional (ternary or
if) that returns 'No "error" allowed in first name' when value.includes('error')
is true and undefined otherwise.
- Around line 54-56: The lastName field's visible label is not
distinct/capitalized; update the form.AppField for name="lastName" to use a
proper, distinct label (e.g., change the f.TextField label prop to "Last Name")
and verify the other field (form.AppField name="firstName") uses "First Name" so
both fields are correctly labeled and consistently capitalized; look for usages
of form.AppField and the f.TextField label prop to make this change.

In `@packages/react-form/tests/createFormHook.test-d.tsx`:
- Around line 993-1021: The test never asserts that ThirdField survived the
second extendForm call; update the test around
useDoublyExtended/withExtendedForm to assert ThirdField is present — add
expectTypeOf(field.ThirdField).toBeFunction() inside the render callback
(alongside the existing Test and ExtendedField checks) and also assert the
doublyExtendedForm exposes the extended field (e.g., an assertion that the
useDoublyExtended result exposes ThirdField on AppField or the equivalent
exported shape) so the chaining regression would fail if the second extension
drops the key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 89488856-d987-469f-8a56-da5c672e2719

📥 Commits

Reviewing files that changed from the base of the PR and between 4276176 and 23d9e03.

⛔ Files ignored due to path filters (2)
  • examples/react/composition/public/emblem-light.svg is excluded by !**/*.svg
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (14)
  • docs/framework/react/guides/form-composition.md
  • examples/react/composition/.eslintrc.cjs
  • examples/react/composition/.gitignore
  • examples/react/composition/README.md
  • examples/react/composition/index.html
  • examples/react/composition/package.json
  • examples/react/composition/src/AppForm/AppForm.tsx
  • examples/react/composition/src/AppForm/FieldComponents/TextField.tsx
  • examples/react/composition/src/AppForm/FormComponents/SubmitButton.tsx
  • examples/react/composition/src/index.tsx
  • examples/react/composition/tsconfig.json
  • packages/react-form/src/createFormHook.tsx
  • packages/react-form/tests/createFormHook.test-d.tsx
  • packages/react-form/tests/createFormHook.test.tsx

Copy link
Copy Markdown
Contributor

@LeCarbonator LeCarbonator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Essentially done! Once the threads are resolved / answered, this'll be ready to merge.

expect(getByLabelText('C')).toHaveValue('valueC')
})

it('should work with withForm after extendForm', () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if I do withCoreForm and pass it the extended form? Does it error?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants